import {
  BufferAttribute,
  BufferGeometry,
  FileLoader,
  Loader
} from "../../build/three.module.js";

var DRACOLoader = function (manager) {

  Loader.call(this, manager);

  this.decoderPath = '';
  this.decoderConfig = {};
  this.decoderBinary = null;
  this.decoderPending = null;

  this.workerLimit = 4;
  this.workerPool = [];
  this.workerNextTaskID = 1;
  this.workerSourceURL = '';

  this.defaultAttributeIDs = {
    position: 'POSITION',
    normal: 'NORMAL',
    color: 'COLOR',
    uv: 'TEX_COORD'
  };
  this.defaultAttributeTypes = {
    position: 'Float32Array',
    normal: 'Float32Array',
    color: 'Float32Array',
    uv: 'Float32Array'
  };

};

DRACOLoader.prototype = Object.assign(Object.create(Loader.prototype), {

  constructor: DRACOLoader,

  setDecoderPath: function (path) {

    this.decoderPath = path;

    return this;

  },

  setDecoderConfig: function (config) {

    this.decoderConfig = config;

    return this;

  },

  setWorkerLimit: function (workerLimit) {

    this.workerLimit = workerLimit;

    return this;

  },

  /** @deprecated */
  setVerbosity: function () {

    console.warn('THREE.DRACOLoader: The .setVerbosity() method has been removed.');

  },

  /** @deprecated */
  setDrawMode: function () {

    console.warn('THREE.DRACOLoader: The .setDrawMode() method has been removed.');

  },

  /** @deprecated */
  setSkipDequantization: function () {

    console.warn('THREE.DRACOLoader: The .setSkipDequantization() method has been removed.');

  },

  load: function (url, onLoad, onProgress, onError) {

    var loader = new FileLoader(this.manager);

    loader.setPath(this.path);
    loader.setResponseType('arraybuffer');
    loader.setRequestHeader(this.requestHeader);
    loader.setWithCredentials(this.withCredentials);

    loader.load(url, (buffer) => {

      var taskConfig = {
        attributeIDs: this.defaultAttributeIDs,
        attributeTypes: this.defaultAttributeTypes,
        useUniqueIDs: false
      };

      this.decodeGeometry(buffer, taskConfig)
        .then(onLoad)
        .catch(onError);

    }, onProgress, onError);

  },

  /** @deprecated Kept for backward-compatibility with previous DRACOLoader versions. */
  decodeDracoFile: function (buffer, callback, attributeIDs, attributeTypes) {

    var taskConfig = {
      attributeIDs: attributeIDs || this.defaultAttributeIDs,
      attributeTypes: attributeTypes || this.defaultAttributeTypes,
      useUniqueIDs: !!attributeIDs
    };

    this.decodeGeometry(buffer, taskConfig).then(callback);

  },

  decodeGeometry: function (buffer, taskConfig) {

    // TODO: For backward-compatibility, support 'attributeTypes' objects containing
    // references (rather than names) to typed array constructors. These must be
    // serialized before sending them to the worker.
    for (var attribute in taskConfig.attributeTypes) {

      var type = taskConfig.attributeTypes[attribute];

      if (type.BYTES_PER_ELEMENT !== undefined) {

        taskConfig.attributeTypes[attribute] = type.name;

      }

    }

    //

    var taskKey = JSON.stringify(taskConfig);

    // Check for an existing task using this buffer. A transferred buffer cannot be transferred
    // again from this thread.
    if (DRACOLoader.taskCache.has(buffer)) {

      var cachedTask = DRACOLoader.taskCache.get(buffer);

      if (cachedTask.key === taskKey) {

        return cachedTask.promise;

      } else if (buffer.byteLength === 0) {

        // Technically, it would be possible to wait for the previous task to complete,
        // transfer the buffer back, and decode again with the second configuration. That
        // is complex, and I don't know of any reason to decode a Draco buffer twice in
        // different ways, so this is left unimplemented.
        throw new Error(
          'THREE.DRACOLoader: Unable to re-decode a buffer with different ' +
          'settings. Buffer has already been transferred.'
        );

      }

    }

    //

    var worker;
    var taskID = this.workerNextTaskID++;
    var taskCost = buffer.byteLength;

    // Obtain a worker and assign a task, and construct a geometry instance
    // when the task completes.
    var geometryPending = this._getWorker(taskID, taskCost)
      .then((_worker) => {

        worker = _worker;

        return new Promise((resolve, reject) => {

          worker._callbacks[taskID] = {resolve, reject};

          worker.postMessage({type: 'decode', id: taskID, taskConfig, buffer}, [buffer]);

          // this.debug();

        });

      })
      .then((message) => this._createGeometry(message.geometry));

    // Remove task from the task list.
    // Note: replaced '.finally()' with '.catch().then()' block - iOS 11 support (#19416)
    geometryPending
      .catch(() => true)
      .then(() => {

        if (worker && taskID) {

          this._releaseTask(worker, taskID);

          // this.debug();

        }

      });

    // Cache the task result.
    DRACOLoader.taskCache.set(buffer, {

      key: taskKey,
      promise: geometryPending

    });

    return geometryPending;

  },

  _createGeometry: function (geometryData) {

    var geometry = new BufferGeometry();

    if (geometryData.index) {

      geometry.setIndex(new BufferAttribute(geometryData.index.array, 1));

    }

    for (var i = 0; i < geometryData.attributes.length; i++) {

      var attribute = geometryData.attributes[i];
      var name = attribute.name;
      var array = attribute.array;
      var itemSize = attribute.itemSize;

      geometry.setAttribute(name, new BufferAttribute(array, itemSize));

    }

    return geometry;

  },

  _loadLibrary: function (url, responseType) {

    var loader = new FileLoader(this.manager);
    loader.setPath(this.decoderPath);
    loader.setResponseType(responseType);
    loader.setWithCredentials(this.withCredentials);

    return new Promise((resolve, reject) => {

      loader.load(url, resolve, undefined, reject);

    });

  },

  preload: function () {

    this._initDecoder();

    return this;

  },

  _initDecoder: function () {

    if (this.decoderPending) return this.decoderPending;

    var useJS = typeof WebAssembly !== 'object' || this.decoderConfig.type === 'js';
    var librariesPending = [];

    if (useJS) {

      librariesPending.push(this._loadLibrary('draco_decoder.js', 'text'));

    } else {

      librariesPending.push(this._loadLibrary('draco_wasm_wrapper.js', 'text'));
      librariesPending.push(this._loadLibrary('draco_decoder.wasm', 'arraybuffer'));

    }

    this.decoderPending = Promise.all(librariesPending)
      .then((libraries) => {

        var jsContent = libraries[0];

        if (!useJS) {

          this.decoderConfig.wasmBinary = libraries[1];

        }

        var fn = DRACOLoader.DRACOWorker.toString();

        var body = [
          '/* draco decoder */',
          jsContent,
          '',
          '/* worker */',
          fn.substring(fn.indexOf('{') + 1, fn.lastIndexOf('}'))
        ].join('\n');

        this.workerSourceURL = URL.createObjectURL(new Blob([body]));

      });

    return this.decoderPending;

  },

  _getWorker: function (taskID, taskCost) {

    return this._initDecoder().then(() => {

      if (this.workerPool.length < this.workerLimit) {

        var worker = new Worker(this.workerSourceURL);

        worker._callbacks = {};
        worker._taskCosts = {};
        worker._taskLoad = 0;

        worker.postMessage({type: 'init', decoderConfig: this.decoderConfig});

        worker.onmessage = function (e) {

          var message = e.data;

          switch (message.type) {

            case 'decode':
              worker._callbacks[message.id].resolve(message);
              break;

            case 'error':
              worker._callbacks[message.id].reject(message);
              break;

            default:
              console.error('THREE.DRACOLoader: Unexpected message, "' + message.type + '"');

          }

        };

        this.workerPool.push(worker);

      } else {

        this.workerPool.sort(function (a, b) {

          return a._taskLoad > b._taskLoad ? -1 : 1;

        });

      }

      var worker = this.workerPool[this.workerPool.length - 1];
      worker._taskCosts[taskID] = taskCost;
      worker._taskLoad += taskCost;
      return worker;

    });

  },

  _releaseTask: function (worker, taskID) {

    worker._taskLoad -= worker._taskCosts[taskID];
    delete worker._callbacks[taskID];
    delete worker._taskCosts[taskID];

  },

  debug: function () {

    console.log('Task load: ', this.workerPool.map((worker) => worker._taskLoad));

  },

  dispose: function () {

    for (var i = 0; i < this.workerPool.length; ++i) {

      this.workerPool[i].terminate();

    }

    this.workerPool.length = 0;

    return this;

  }

});

/* WEB WORKER */

DRACOLoader.DRACOWorker = function () {

  var decoderConfig;
  var decoderPending;

  onmessage = function (e) {

    var message = e.data;

    switch (message.type) {

      case 'init':
        decoderConfig = message.decoderConfig;
        decoderPending = new Promise(function (resolve/*, reject*/) {

          decoderConfig.onModuleLoaded = function (draco) {

            // Module is Promise-like. Wrap before resolving to avoid loop.
            resolve({draco: draco});

          };

          DracoDecoderModule(decoderConfig); // eslint-disable-line no-undef

        });
        break;

      case 'decode':
        var buffer = message.buffer;
        var taskConfig = message.taskConfig;
        decoderPending.then((module) => {

          var draco = module.draco;
          var decoder = new draco.Decoder();
          var decoderBuffer = new draco.DecoderBuffer();
          decoderBuffer.Init(new Int8Array(buffer), buffer.byteLength);

          try {

            var geometry = decodeGeometry(draco, decoder, decoderBuffer, taskConfig);

            var buffers = geometry.attributes.map((attr) => attr.array.buffer);

            if (geometry.index) buffers.push(geometry.index.array.buffer);

            self.postMessage({type: 'decode', id: message.id, geometry}, buffers);

          } catch (error) {

            console.error(error);

            self.postMessage({type: 'error', id: message.id, error: error.message});

          } finally {

            draco.destroy(decoderBuffer);
            draco.destroy(decoder);

          }

        });
        break;

    }

  };

  function decodeGeometry(draco, decoder, decoderBuffer, taskConfig) {

    var attributeIDs = taskConfig.attributeIDs;
    var attributeTypes = taskConfig.attributeTypes;

    var dracoGeometry;
    var decodingStatus;

    var geometryType = decoder.GetEncodedGeometryType(decoderBuffer);

    if (geometryType === draco.TRIANGULAR_MESH) {

      dracoGeometry = new draco.Mesh();
      decodingStatus = decoder.DecodeBufferToMesh(decoderBuffer, dracoGeometry);

    } else if (geometryType === draco.POINT_CLOUD) {

      dracoGeometry = new draco.PointCloud();
      decodingStatus = decoder.DecodeBufferToPointCloud(decoderBuffer, dracoGeometry);

    } else {

      throw new Error('THREE.DRACOLoader: Unexpected geometry type.');

    }

    if (!decodingStatus.ok() || dracoGeometry.ptr === 0) {

      throw new Error('THREE.DRACOLoader: Decoding failed: ' + decodingStatus.error_msg());

    }

    var geometry = {index: null, attributes: []};

    // Gather all vertex attributes.
    for (var attributeName in attributeIDs) {

      var attributeType = self[attributeTypes[attributeName]];

      var attribute;
      var attributeID;

      // A Draco file may be created with default vertex attributes, whose attribute IDs
      // are mapped 1:1 from their semantic name (POSITION, NORMAL, ...). Alternatively,
      // a Draco file may contain a custom set of attributes, identified by known unique
      // IDs. glTF files always do the latter, and `.drc` files typically do the former.
      if (taskConfig.useUniqueIDs) {

        attributeID = attributeIDs[attributeName];
        attribute = decoder.GetAttributeByUniqueId(dracoGeometry, attributeID);

      } else {

        attributeID = decoder.GetAttributeId(dracoGeometry, draco[attributeIDs[attributeName]]);

        if (attributeID === -1) continue;

        attribute = decoder.GetAttribute(dracoGeometry, attributeID);

      }

      geometry.attributes.push(decodeAttribute(draco, decoder, dracoGeometry, attributeName, attributeType, attribute));

    }

    // Add index.
    if (geometryType === draco.TRIANGULAR_MESH) {

      // Generate mesh faces.
      var numFaces = dracoGeometry.num_faces();
      var numIndices = numFaces * 3;
      var index = new Uint32Array(numIndices);
      var indexArray = new draco.DracoInt32Array();

      for (var i = 0; i < numFaces; ++i) {

        decoder.GetFaceFromMesh(dracoGeometry, i, indexArray);

        for (var j = 0; j < 3; ++j) {

          index[i * 3 + j] = indexArray.GetValue(j);

        }

      }

      geometry.index = {array: index, itemSize: 1};

      draco.destroy(indexArray);

    }

    draco.destroy(dracoGeometry);

    return geometry;

  }

  function decodeAttribute(draco, decoder, dracoGeometry, attributeName, attributeType, attribute) {

    var numComponents = attribute.num_components();
    var numPoints = dracoGeometry.num_points();
    var numValues = numPoints * numComponents;
    var dracoArray;

    var array;

    switch (attributeType) {

      case Float32Array:
        dracoArray = new draco.DracoFloat32Array();
        decoder.GetAttributeFloatForAllPoints(dracoGeometry, attribute, dracoArray);
        array = new Float32Array(numValues);
        break;

      case Int8Array:
        dracoArray = new draco.DracoInt8Array();
        decoder.GetAttributeInt8ForAllPoints(dracoGeometry, attribute, dracoArray);
        array = new Int8Array(numValues);
        break;

      case Int16Array:
        dracoArray = new draco.DracoInt16Array();
        decoder.GetAttributeInt16ForAllPoints(dracoGeometry, attribute, dracoArray);
        array = new Int16Array(numValues);
        break;

      case Int32Array:
        dracoArray = new draco.DracoInt32Array();
        decoder.GetAttributeInt32ForAllPoints(dracoGeometry, attribute, dracoArray);
        array = new Int32Array(numValues);
        break;

      case Uint8Array:
        dracoArray = new draco.DracoUInt8Array();
        decoder.GetAttributeUInt8ForAllPoints(dracoGeometry, attribute, dracoArray);
        array = new Uint8Array(numValues);
        break;

      case Uint16Array:
        dracoArray = new draco.DracoUInt16Array();
        decoder.GetAttributeUInt16ForAllPoints(dracoGeometry, attribute, dracoArray);
        array = new Uint16Array(numValues);
        break;

      case Uint32Array:
        dracoArray = new draco.DracoUInt32Array();
        decoder.GetAttributeUInt32ForAllPoints(dracoGeometry, attribute, dracoArray);
        array = new Uint32Array(numValues);
        break;

      default:
        throw new Error('THREE.DRACOLoader: Unexpected attribute type.');

    }

    for (var i = 0; i < numValues; i++) {

      array[i] = dracoArray.GetValue(i);

    }

    draco.destroy(dracoArray);

    return {
      name: attributeName,
      array: array,
      itemSize: numComponents
    };

  }

};

DRACOLoader.taskCache = new WeakMap();

/** Deprecated static methods */

/** @deprecated */
DRACOLoader.setDecoderPath = function () {

  console.warn('THREE.DRACOLoader: The .setDecoderPath() method has been removed. Use instance methods.');

};

/** @deprecated */
DRACOLoader.setDecoderConfig = function () {

  console.warn('THREE.DRACOLoader: The .setDecoderConfig() method has been removed. Use instance methods.');

};

/** @deprecated */
DRACOLoader.releaseDecoderModule = function () {

  console.warn('THREE.DRACOLoader: The .releaseDecoderModule() method has been removed. Use instance methods.');

};

/** @deprecated */
DRACOLoader.getDecoderModule = function () {

  console.warn('THREE.DRACOLoader: The .getDecoderModule() method has been removed. Use instance methods.');

};

export {DRACOLoader};
